從最初的 BMI 計算、飲水紀錄、睡眠紀錄,到後來的 AI 健康摘要、多輪對話,以及聊天紀錄資料庫保存。
現在我們的智慧健康助理,已經可以透過 Spring Boot 提供各種 API。
不過到目前為止,我們幾乎都使用 Postman 測試。
例如:
POST http://localhost:8080/health/ai/conversation
再手動輸入:
{
"question": "什麼是 BMI?"
}
雖然這對開發者來說很方便,但如果未來想讓一般使用者操作,總不能要求每個人都先學會 Postman 吧 XD
所以今天決定挑戰一個我期待很久的功能:
替智慧健康助理建立自己的聊天網頁!
今天的目標是:
使用 HTML、CSS 和 JavaScript 建立簡易聊天介面,透過
fetch()呼叫 Spring Boot API,讓使用者可以直接在瀏覽器中與 AI 健康助理進行多輪對話。
今天準備建立一個簡單的聊天網頁。
畫面大概會長這樣:
┌──────────────────────────────────────┐
│ │
│ 智慧健康助理 │
│ AI Health Assistant │
│ │
├──────────────────────────────────────┤
│ │
│ AI:你好!有什麼健康知識想了解嗎? │
│ │
│ 使用者:什麼是 BMI? │
│ │
│ AI:BMI 是身體質量指數,常用於 │
│ 成人體位的初步評估。 │
│ │
│ 使用者:怎麼計算? │
│ │
│ AI:體重除以身高的平方。 │
│ │
├──────────────────────────────────────┤
│ │
│ 請輸入你的問題…… [送出] │
│ │
│ [開始新對話] │
│ │
└──────────────────────────────────────┘
今天會完成以下功能:
fetch() 呼叫 AI 對話 API。conversationId,支援多輪對話。今天不需要新增資料庫,也不需要修改 AI 模型。
因為我們要做的,是把前面已經完成的功能整合成可以操作的網頁。
在開始之前,先認識兩個常見的名詞:
Frontend
以及:
Backend
也就是:
前端與後端。
前端主要負責使用者看到的畫面,以及操作時的互動。
例如:
聊天視窗
輸入框
送出按鈕
AI 回答顯示
後端則負責處理資料與程式邏輯。
例如:
接收使用者問題
查詢歷史對話
呼叫 AI API
保存聊天紀錄
回傳 JSON
今天的技術分工如下:
| 技術 | 負責工作 |
|---|---|
| HTML | 建立網頁結構 |
| CSS | 設計畫面樣式 |
| JavaScript | 處理使用者互動 |
| Fetch API | 發送 HTTP Request |
| Spring Boot | 提供後端 API |
| H2 Database | 保存聊天紀錄 |
| OpenAI API | 產生 AI 回答 |
整個流程可以表示成:
使用者
↓
HTML 聊天畫面
↓
JavaScript
↓
fetch()
↓
Spring Boot Controller
↓
AIConversationService
│
├── ConversationRepository
│ ↓
│ H2 Database
│
└── AIService
↓
OpenAI API
↓
AI 回答
↓
Spring Boot 回傳 JSON
↓
JavaScript 更新畫面
↓
使用者看到 AI 回答
這樣我們就把前端和後端串起來了。
之前我們建立的 Spring Boot 專案,主要用來提供 REST API。
例如:
GET /health/summary
或是:
POST /health/ai/conversation
但其實 Spring Boot 也可以直接提供靜態網頁。
只要把 HTML、CSS、JavaScript 放在指定資料夾,Spring Boot 就能提供這些檔案。
今天會使用:
src/main/resources/static
這個資料夾。
我們準備新增三個檔案:
index.html
style.css
app.js
最後的結構如下:
src
└── main
└── resources
├── application.properties
├── schema.sql
│
└── static
├── index.html
├── style.css
└── app.js
其中:
index.html
是主要網頁。
style.css
負責畫面設計。
app.js
負責呼叫 API 與更新聊天內容。
因為我們之前已經加入 spring-boot-starter-web,今天不需要額外安裝前端框架。
也不需要先學 React 或 Vue。
先用最基本的 HTML、CSS 和 JavaScript,就可以完成今天的功能!
首先在:
src/main/resources/static
新增:
index.html
完整程式如下:
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
>
<title>智慧健康助理</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div class="chat-container">
<header class="chat-header">
<div>
<h1>智慧健康助理</h1>
<p>AI Health Assistant</p>
</div>
<button
id="newChatButton"
class="new-chat-button"
type="button"
>
新對話
</button>
</header>
<main
id="chatMessages"
class="chat-messages"
aria-live="polite"
aria-relevant="additions"
>
<div class="message assistant">
<div class="message-content">
你好!我是智慧健康助理。
你可以詢問我一般健康知識,
例如 BMI、飲水習慣或睡眠相關問題。
</div>
</div>
</main>
<div
id="statusMessage"
class="status-message"
role="status"
></div>
<form id="chatForm" class="chat-input-area">
<input
type="text"
id="questionInput"
placeholder="請輸入你的問題……"
maxlength="500"
autocomplete="off"
aria-label="聊天問題"
required
>
<button
id="sendButton"
type="submit"
>
送出
</button>
</form>
<footer class="chat-footer">
本系統僅提供一般健康資訊,
不取代專業醫療診斷或治療。
</footer>
</div>
<script src="/app.js"></script>
</body>
</html>
這個 HTML 看起來有點長,但其實可以分成四個主要區域。
首先是:
<header class="chat-header">
這裡會顯示:
智慧健康助理
AI Health Assistant
以及:
新對話
按鈕。
未來當使用者想重新開始聊天,就可以按下這個按鈕。
接著是:
<main id="chatMessages" class="chat-messages">
這裡負責顯示所有聊天訊息。
例如:
AI:你好!
使用者:什麼是 BMI?
AI:BMI 是身體質量指數。
我們給它一個 ID:
chatMessages
這樣 JavaScript 就可以找到這個區域,並且動態加入新的訊息。
接著是:
<form id="chatForm" class="chat-input-area">
裡面包含:
<input>
以及:
<button>
使用者可以在輸入框輸入問題,再按下送出。
這裡使用 <form>,也可以讓使用者直接按 Enter 送出問題。
最後是:
<footer class="chat-footer">
顯示:
本系統僅提供一般健康資訊,
不取代專業醫療診斷或治療。
因為今天的專案涉及健康資訊,所以我希望使用者能清楚知道:
AI 可以協助整理一般健康知識,但不能取代醫療專業人員。
接下來新增:
style.css
這次想讓畫面看起來簡潔一點,所以選擇白色搭配淺綠色,呈現比較清爽的健康主題。
完整程式如下:
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 20px;
font-family:
Arial,
"Noto Sans TC",
sans-serif;
background-color: #f1f7f5;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.chat-container {
width: 100%;
max-width: 800px;
height: min(85vh, 750px);
background-color: #ffffff;
border-radius: 18px;
box-shadow:
0 8px 30px rgba(0, 0, 0, 0.08);
display: flex;
flex-direction: column;
overflow: hidden;
}
.chat-header {
padding: 20px 24px;
background-color: #4a9b84;
color: white;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.chat-header h1 {
margin: 0;
font-size: 24px;
}
.chat-header p {
margin: 5px 0 0;
font-size: 13px;
opacity: 0.9;
}
.new-chat-button {
padding: 9px 14px;
border: 1px solid white;
border-radius: 8px;
background-color: transparent;
color: white;
cursor: pointer;
}
.new-chat-button:hover {
background-color: rgba(255, 255, 255, 0.15);
}
.chat-messages {
flex: 1;
padding: 24px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 16px;
}
.message {
display: flex;
width: 100%;
}
.message.user {
justify-content: flex-end;
}
.message.assistant {
justify-content: flex-start;
}
.message-content {
max-width: 75%;
padding: 12px 16px;
border-radius: 14px;
line-height: 1.7;
font-size: 15px;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.message.user .message-content {
background-color: #d9f0e8;
color: #244b3f;
border-bottom-right-radius: 4px;
}
.message.assistant .message-content {
background-color: #f1f3f5;
color: #333333;
border-bottom-left-radius: 4px;
}
.status-message {
min-height: 24px;
padding: 0 24px;
font-size: 13px;
color: #777777;
}
.chat-input-area {
display: flex;
gap: 10px;
padding: 16px 20px;
border-top: 1px solid #eeeeee;
}
.chat-input-area input {
flex: 1;
min-width: 0;
padding: 12px 14px;
border: 1px solid #dddddd;
border-radius: 10px;
font-size: 15px;
outline: none;
}
.chat-input-area input:focus {
border-color: #4a9b84;
}
.chat-input-area button {
padding: 12px 20px;
border: none;
border-radius: 10px;
background-color: #4a9b84;
color: white;
font-size: 15px;
cursor: pointer;
}
.chat-input-area button:hover {
background-color: #397c69;
}
.chat-input-area button:disabled,
.new-chat-button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.chat-footer {
padding: 10px 20px 16px;
text-align: center;
color: #888888;
font-size: 12px;
}
@media (max-width: 600px) {
body {
padding: 0;
}
.chat-container {
height: 100dvh;
max-width: none;
border-radius: 0;
}
.chat-header {
padding: 16px;
}
.chat-header h1 {
font-size: 20px;
}
.chat-messages {
padding: 16px;
}
.message-content {
max-width: 88%;
}
.chat-input-area {
padding: 12px;
}
.chat-input-area button {
padding: 12px 14px;
}
}
這裡我們使用了幾個 CSS 概念。
首先看到:
display: flex;
這是 CSS 的 Flexbox 排版方式。
今天我們主要用它來安排聊天畫面。
例如:
.chat-container {
display: flex;
flex-direction: column;
}
代表聊天介面會由上到下排列。
Header
↓
Messages
↓
Status
↓
Input
↓
Footer
另外:
.message.user {
justify-content: flex-end;
}
代表使用者訊息靠右。
而:
.message.assistant {
justify-content: flex-start;
}
代表 AI 訊息靠左。
這樣就能做出常見的聊天介面效果。
接下來看到:
.chat-messages {
flex: 1;
overflow-y: auto;
}
其中:
flex: 1;
讓聊天區域使用剩餘空間。
而:
overflow-y: auto;
代表當聊天訊息太多時,可以在這個區域上下捲動。
這樣就不會因為對話越來越長,導致整個網頁一直往下延伸。
今天還加入:
@media (max-width: 600px)
這是 CSS 的 Media Query。
可以根據螢幕大小調整版面。
例如:
.chat-container {
height: 100dvh;
max-width: none;
border-radius: 0;
}
當使用者使用手機時,聊天畫面就可以更接近全螢幕。
雖然今天只是簡易版,但至少不會只能在電腦上使用。
接下來是今天最重要的部分!
我們已經有 HTML 畫面,也完成 CSS 樣式。
但目前按下「送出」還不會發生任何事情。
所以接下來要使用 JavaScript,讓網頁可以和 Spring Boot API 溝通。
新增:
app.js
完整程式如下:
const API_URL = "/health/ai/conversation";
const chatForm = document.getElementById("chatForm");
const questionInput = document.getElementById("questionInput");
const sendButton = document.getElementById("sendButton");
const newChatButton = document.getElementById("newChatButton");
const chatMessages = document.getElementById("chatMessages");
const statusMessage = document.getElementById("statusMessage");
let conversationId = null;
let isLoading = false;
function addMessage(role, content) {
const messageDiv = document.createElement("div");
messageDiv.classList.add("message", role);
const contentDiv = document.createElement("div");
contentDiv.classList.add("message-content");
contentDiv.textContent = content;
messageDiv.appendChild(contentDiv);
chatMessages.appendChild(messageDiv);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
function setLoading(loading) {
isLoading = loading;
sendButton.disabled = loading;
questionInput.disabled = loading;
newChatButton.disabled = loading;
statusMessage.textContent = loading
? "AI 正在思考中……"
: "";
}
async function sendQuestion(question) {
if (isLoading) {
return;
}
setLoading(true);
addMessage("user", question);
const requestBody = {
question: question
};
if (conversationId !== null) {
requestBody.conversationId = conversationId;
}
try {
const response = await fetch(API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(requestBody)
});
if (!response.ok) {
throw new Error(
"API 請求失敗,HTTP Status:" + response.status
);
}
const data = await response.json();
if (!data.conversationId || !data.answer) {
throw new Error("API 回傳資料格式不正確");
}
conversationId = data.conversationId;
addMessage("assistant", data.answer);
} catch (error) {
console.error("Chat request failed:", error);
addMessage(
"assistant",
"抱歉,目前無法取得回答,請稍後再試。"
);
} finally {
setLoading(false);
questionInput.focus();
}
}
chatForm.addEventListener("submit", function (event) {
event.preventDefault();
if (isLoading) {
return;
}
const question = questionInput.value.trim();
if (question === "") {
return;
}
if (question.length > 500) {
alert("問題不能超過 500 個字元。");
return;
}
questionInput.value = "";
sendQuestion(question);
});
newChatButton.addEventListener("click", function () {
if (isLoading) {
return;
}
conversationId = null;
chatMessages.replaceChildren();
addMessage(
"assistant",
"已開始新對話!你可以輸入新的健康相關問題。"
);
questionInput.focus();
});
這樣就完成今天最核心的 JavaScript 程式。
接下來我們把它拆開來看。
首先看到:
const chatForm = document.getElementById("chatForm");
這裡使用:
document.getElementById()
取得 HTML 中指定 ID 的元素。
例如:
<form id="chatForm">
JavaScript 就可以透過:
document.getElementById("chatForm")
找到它。
其他像是:
const questionInput =
document.getElementById("questionInput");
就是取得輸入框。
而:
const chatMessages =
document.getElementById("chatMessages");
則是取得聊天訊息區域。
這樣 JavaScript 才能讀取使用者輸入,以及修改網頁內容。
接下來看到:
function addMessage(role, content)
這個 Function 負責將訊息顯示在畫面上。
例如:
addMessage("user", "什麼是 BMI?");
就會新增一則使用者訊息。
而:
addMessage("assistant", "BMI 是身體質量指數。");
則會新增一則 AI 訊息。
其中:
document.createElement("div")
代表建立新的 HTML 元素。
接著:
messageDiv.classList.add("message", role);
替元素加入 CSS Class。
例如:
message user
或:
message assistant
這樣 CSS 就能決定訊息應該靠左還是靠右。
最後:
chatMessages.appendChild(messageDiv);
把訊息加入聊天區域。
今天有一個值得注意的地方。
我們使用:
contentDiv.textContent = content;
而不是:
contentDiv.innerHTML = content;
為什麼?
因為使用者輸入和 AI 回覆,都不應該直接被當成 HTML 執行。
例如,假設有人輸入:
<script>alert("Hello")</script>
如果不小心把這段文字當成 HTML 插入,就可能產生安全問題。
使用:
textContent
可以把內容當成一般文字顯示。
所以今天不需要讓 AI 回覆包含可執行的 HTML。
這是建立聊天介面時很重要的安全習慣。
接下來是今天最重要的概念:
fetch()
它可以讓瀏覽器發送 HTTP Request。
例如:
const response = await fetch(API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(requestBody)
});
這段程式會向 Spring Boot 發送:
POST /health/ai/conversation
並傳送 JSON。
例如:
{
"question": "什麼是 BMI?"
}
如果是第二輪對話,就會包含:
{
"conversationId": "550e8400-e29b-41d4-a716-446655440000",
"question": "那它怎麼計算?"
}
這和我們之前在 Postman 裡面做的事情其實一樣。
只是現在改成由 JavaScript 自動完成。
在發送 Request 時,我們使用:
JSON.stringify(requestBody)
因為 JavaScript 裡面的物件:
const requestBody = {
question: "什麼是 BMI?"
};
不是直接要傳送的 JSON 字串。
所以需要透過:
JSON.stringify()
轉換。
轉換後就會變成:
{
"question": "什麼是 BMI?"
}
這樣 Spring Boot 才能透過:
@RequestBody
接收資料。
也就是:
JavaScript Object
↓
JSON.stringify()
↓
JSON Request Body
↓
Spring Boot @RequestBody
今天另一個新的語法是:
async function sendQuestion(question)
以及:
await fetch(...)
因為呼叫 AI API 需要時間。
有時候可能只要一兩秒,有時候可能需要更久。
所以 JavaScript 不能假設 Request 發送後,AI 就會立刻回覆。
我們使用:
async
宣告非同步 Function。
再使用:
await
等待 Promise 完成。
例如:
const response = await fetch(API_URL);
這裡會等待 HTTP Response。
接著:
const data = await response.json();
再將 Response Body 解析成 JavaScript 物件。
這樣就可以取得:
data.answer
以及:
data.conversationId
最後更新畫面。
還記得 Day 25 建立的多輪對話功能嗎?
當時我們使用:
conversationId
區分不同對話。
今天在 JavaScript 裡面也需要保存這個 ID。
首先宣告:
let conversationId = null;
當使用者第一次發送問題時:
const requestBody = {
question: question
};
因為:
conversationId === null
所以不會附上對話 ID。
Spring Boot 就會建立新對話。
例如回傳:
{
"conversationId": "550e8400-e29b-41d4-a716-446655440000",
"answer": "BMI 是身體質量指數。"
}
接著 JavaScript 會執行:
conversationId = data.conversationId;
將 ID 保存起來。
當使用者再次提問:
if (conversationId !== null) {
requestBody.conversationId = conversationId;
}
就會把同一個 ID 傳給後端。
這樣 AI 就可以取得之前的歷史訊息,繼續對話。
整個流程如下:
第一次提問
↓
conversationId = null
↓
Spring Boot 建立新對話
↓
回傳 conversationId
↓
JavaScript 保存 ID
↓
第二次提問
↓
帶上相同 conversationId
↓
Spring Boot 查詢歷史對話
↓
AI 根據上下文回答
這樣就把之前建立的多輪對話功能,真正整合到網頁上了!
使用 AI 時,有一個常見的問題。
就是使用者按下送出後,可能需要等待幾秒鐘。
如果畫面完全沒有反應,使用者可能會以為按鈕沒有作用,然後一直重複點擊。
所以今天加入:
function setLoading(loading)
當 API 正在處理時:
setLoading(true);
畫面就會顯示:
AI 正在思考中……
同時停用:
sendButton.disabled = loading;
以及:
questionInput.disabled = loading;
避免使用者在上一個問題還沒完成時,又發送下一個問題。
當 API 回覆後:
setLoading(false);
就會重新啟用輸入框和按鈕。
這樣使用者就能清楚知道系統目前的狀態。
今天我們也使用:
try
catch
以及:
finally
處理 API 請求。
例如:
try {
// 呼叫 API
} catch (error) {
// 發生錯誤
} finally {
// 不論成功或失敗都會執行
}
如果 Spring Boot 沒有啟動,或是 AI API 發生錯誤,就會進入:
catch
並顯示:
抱歉,目前無法取得回答,請稍後再試。
這樣使用者就不會只看到空白畫面。
另外要注意,今天的簡易版本如果 API 請求失敗,畫面上的問題仍然會保留,但後端不一定已經保存這一輪對話。
因此這個畫面比較適合作為本機功能展示。
未來如果要做成正式聊天系統,還可以加入重試機制與訊息狀態管理。
目前我們已經可以進行多輪對話。
但如果使用者想開始另一個主題呢?
例如:
對話 A:BMI
結束後想開始:
對話 B:睡眠習慣
就需要建立新的對話。
所以今天在 HTML 裡面加入:
<button id="newChatButton">
新對話
</button>
再透過 JavaScript:
newChatButton.addEventListener("click", function () {
if (isLoading) {
return;
}
conversationId = null;
chatMessages.replaceChildren();
addMessage(
"assistant",
"已開始新對話!你可以輸入新的健康相關問題。"
);
questionInput.focus();
});
這裡最重要的是:
conversationId = null;
代表下一次送出問題時,不會附上原本的對話 ID。
Spring Boot 就會建立新的對話。
另外:
chatMessages.replaceChildren();
會清空目前畫面上的聊天訊息。
但要注意:
這個按鈕不會刪除資料庫中的舊對話。
它只是讓目前網頁開始新的聊天。
如果真的想刪除之前的對話,還是需要使用 Day 27 建立的 DELETE API。
接下來確認三個檔案都已經建立:
static
├── index.html
├── style.css
└── app.js
然後重新啟動 Spring Boot。
如果使用 Maven,可以在專案目錄執行:
./mvnw spring-boot:run
Windows 可以使用:
.\mvnw.cmd spring-boot:run
或是直接透過 IDE 執行:
HealthAssistantApplication
記得確認之前設定的 OpenAI API Key 仍然有效。
因為今天的聊天功能會實際呼叫外部 AI API,可能產生 API 使用費用。
啟動成功後,打開:
http://localhost:8080/
如果檔案位置正確,Spring Boot 就會提供:
index.html
畫面應該可以看到:
智慧健康助理
AI Health Assistant
以及聊天訊息區和問題輸入框。
如果網頁沒有正常顯示,可以先確認:
src/main/resources/static/index.html
是否放在正確位置。
另外,今天我們把網頁和 API 都放在同一個 Spring Boot 專案。
因此網頁可以直接呼叫:
fetch("/health/ai/conversation")
不需要另外設定跨來源請求。
如果未來將前端部署到不同網域,才需要進一步處理 CORS 等設定。
接下來在輸入框輸入:
什麼是 BMI?
按下:
送出
畫面會先顯示:
使用者:什麼是 BMI?
接著:
AI 正在思考中……
如果後端成功呼叫 AI API,預期會看到類似:
AI:BMI 是身體質量指數,
通常用於成人體位的初步評估。
實際回答內容會依照模型產生的文字而不同。
這樣就代表:
HTML → JavaScript → Spring Boot → AI API
整個流程已經成功串接!
接著不要按新對話。
直接輸入:
那它怎麼計算?
如果 JavaScript 已經保存前一輪回傳的:
conversationId
就會將相同的 ID 傳給 Spring Boot。
後端便可以從 H2 Database 查詢歷史對話。
預期 AI 可能回答:
AI:BMI 的計算方式是體重(公斤)
除以身高(公尺)的平方。
這樣就代表網頁已經支援多輪對話。
而且這次不需要手動複製 UUID,也不需要開啟 Postman。
所有操作都可以直接在瀏覽器完成!
接下來點擊:
新對話
畫面上的聊天內容會被清空。
接著顯示:
已開始新對話!
你可以輸入新的健康相關問題。
再輸入:
為什麼規律作息很重要?
因為:
conversationId = null;
所以 Spring Boot 會建立另一段新對話。
這樣就可以將不同主題分開。
例如:
Conversation A:BMI
Conversation B:睡眠
而且兩段對話都會保存在 H2 Database 中。
今天我們雖然建立了網頁介面,但資料庫功能仍然沿用之前的設計。
所以可以透過 Day 27 的 API 驗證。
打開 Postman:
GET http://localhost:8080/health/ai/conversations
應該可以看到剛剛建立的對話。
例如:
[
{
"conversationId": "550e8400-e29b-41d4-a716-446655440000",
"createdAt": "2026-10-17T14:00:00",
"messageCount": 4
},
{
"conversationId": "9c1e4b20-2f7a-4c88-a2d3-123456789abc",
"createdAt": "2026-10-17T14:05:00",
"messageCount": 2
}
]
接著使用:
GET /health/ai/conversations/{id}
也可以查看完整聊天紀錄。
這代表我們今天的前端雖然是新建立的,但後端仍然使用前面完成的 API 和資料庫。
這也是我覺得分層開發很方便的地方。
因為我們不用重新寫一次 AI 功能,只需要建立新的操作介面。
今天還有一個值得注意的問題。
我們使用:
let conversationId = null;
保存目前的對話 ID。
但這個變數只存在於目前載入的網頁中。
所以如果使用者重新整理瀏覽器:
重新整理網頁
↓
JavaScript 重新執行
↓
conversationId = null
就會失去目前對話的 ID。
雖然 H2 Database 裡面的聊天紀錄仍然存在,但今天的網頁還沒有提供恢復舊對話的功能。
這也是目前這個版本的限制。
未來可以考慮使用:
sessionStorage
暫時保存對話 ID。
或是使用 Day 27 建立的:
GET /health/ai/conversations
讓使用者選擇想繼續的對話。
不過要注意,瀏覽器儲存對話 ID 並不等於完成身分驗證。
正式系統仍然需要登入機制與對話存取權限。
今天先以完成基本聊天畫面為主。
這點非常重要!
今天我們的 JavaScript 只會呼叫:
fetch("/health/ai/conversation")
也就是我們自己的 Spring Boot API。
它不會直接呼叫:
https://api.openai.com/v1/responses
原因是:
OpenAI API Key 不應該直接放在前端程式碼裡。
例如,千萬不要在 app.js 寫:
const apiKey = "sk-你的金鑰";
因為前端 JavaScript 會被下載到使用者的瀏覽器。
任何人都可能透過瀏覽器開發者工具查看程式碼。
如果 API Key 放在裡面,就可能外洩。
所以今天採用:
瀏覽器
↓
Spring Boot
↓
OpenAI API
讓 API Key 留在後端。
這樣比較符合基本的安全設計。
雖然今天已經可以在瀏覽器聊天,但它仍然是教學用的簡易版本。
目前只要可以連到這個 Spring Boot 服務,就可能呼叫聊天 API。
因此不適合直接公開部署。
正式系統需要加入使用者驗證、存取權限和請求限制。
雖然 Day 27 已經建立查詢 API,但今天的網頁還沒有將它做成可點選的列表。
所以目前只能繼續當前網頁中的對話。
目前 AI 回答使用:
textContent
顯示成純文字。
如果 AI 回覆包含:
**粗體**
畫面就會直接顯示星號。
未來如果要支援 Markdown,需要使用適當的解析與清理機制,避免 XSS 問題。
目前如果 API 發生錯誤,網頁會顯示錯誤提示。
但還沒有提供單則訊息重試按鈕。
雖然後端已經限制歷史訊息數量,但每次 AI 呼叫仍然可能產生費用。
正式系統需要進一步限制請求頻率與使用量。
因為使用者可能輸入個人健康狀況,所以未來如果公開使用,還需要加入隱私告知、資料保護,以及對話刪除等機制。
今天新增了三個前端檔案:
index.html
style.css
app.js
目前專案結構如下:
health-assistant
│
├── src/main/java/com/example/healthassistant
│ │
│ ├── HealthAssistantApplication.java
│ │
│ ├── controller
│ │ ├── HealthController.java
│ │ ├── WaterController.java
│ │ ├── SleepController.java
│ │ ├── HealthSummaryController.java
│ │ └── AIController.java
│ │
│ ├── service
│ │ ├── BmiService.java
│ │ ├── WaterService.java
│ │ ├── SleepService.java
│ │ ├── HealthSummaryService.java
│ │ ├── AIService.java
│ │ ├── AIHealthSummaryService.java
│ │ ├── AIChatService.java
│ │ ├── AIConversationService.java
│ │ ├── ConversationPersistenceService.java
│ │ └── ConversationManagementService.java
│ │
│ ├── repository
│ │ ├── WaterRepository.java
│ │ ├── SleepRepository.java
│ │ └── ConversationRepository.java
│ │
│ ├── dto
│ │ ├── ConversationRequest.java
│ │ ├── ConversationResponse.java
│ │ ├── ConversationMessage.java
│ │ ├── ConversationListResponse.java
│ │ └── ConversationDetailResponse.java
│ │
│ └── exception
│ ├── GlobalExceptionHandler.java
│ └── ConversationNotFoundException.java
│
├── src/main/resources
│ │
│ ├── application.properties
│ ├── schema.sql
│ │
│ └── static
│ ├── index.html
│ ├── style.css
│ └── app.js
│
└── pom.xml
這裡只列出部分 DTO,其他之前建立的健康紀錄 DTO 仍然保留。
現在我們的智慧健康助理不再只是後端 API,而是終於有了可以操作的網頁!
今天成功使用 HTML、CSS 和 JavaScript,替智慧健康助理建立了第一個聊天介面!
原本每次都需要打開 Postman,手動輸入 JSON,再查看 Response。
現在使用者只要開啟瀏覽器:
http://localhost:8080/
就可以直接和 AI 健康助理聊天。
今天完成的功能包括:
fetch() 呼叫 Spring Boot APIJSON.stringify() 傳送 JSONasync 和 await 處理非同步請求conversationId 支援多輪對話目前我們的智慧健康助理已經具備:
智慧健康助理
│
├── 健康紀錄功能
│ ├── BMI 計算與分類
│ ├── 飲水紀錄
│ ├── 睡眠紀錄
│ └── 每日健康摘要
│
├── AI 功能
│ ├── AI 每日健康摘要
│ ├── AI 健康問答
│ ├── AI 多輪對話
│ └── AI 聊天紀錄管理
│
├── 資料庫
│ ├── 飲水紀錄
│ ├── 睡眠紀錄
│ └── AI 對話紀錄
│
└── 網頁介面
├── 聊天訊息顯示
├── 問題輸入
├── AI 回答
├── 多輪對話
└── 開始新對話
今天最大的收穫是:
後端 API 負責提供功能,而前端介面則讓這些功能真正變成使用者可以操作的服務。
以前在 Postman 裡面看到 JSON 回傳時,雖然知道程式成功了,但總覺得它還不像一個完整的應用程式。
直到今天把 HTML、CSS 和 JavaScript 串起來,才終於有一種:
「啊!我真的做出一個可以聊天的 AI 健康助理了!」的感覺 XD
雖然目前還是很基本的版本,但已經完成從前端到後端、資料庫,再到外部 AI API 的整個流程。
今天我們已經建立基本聊天介面。
不過目前的網頁只能聊天,還沒有把之前完成的健康紀錄功能整合進來。
例如:
今日飲水量
今日睡眠時數
以及:
AI 每日健康摘要
另外,Day 27 建立的歷史對話查詢 API,也還沒有真正放進網頁。
所以下一篇準備挑戰:
將健康資料與 AI 聊天功能整合到同一個網頁!
預計會完成:
讓我們的專案不只是 AI 聊天機器人,而是更接近一個真正的智慧健康助理!
最後兩天,繼續加油!